von Daniel Dietrich
Die imperative Programmierung ist eng angelehnt an die Arbeitsweise der Von-Neumann-Architektur heutiger Computer. Wertzuweisungen, Schleifen und Bedingungen lassen sich direkt auf das Verändern von Speicherinhalten und das Durchführen von Sprüngen bei der Interpretation von Daten übertragen. Spätestens bei der nebenläufigen Programmierung kommen wir hier allerdings schnell an unsere Grenzen, denn es fällt uns schwer, alle Ausführungspfade eines nebenläufigen Programms vorherzusehen. Es bedarf einer Abstraktion mit anderen Sprachmitteln.
Ich möchte anhand des Beispiels der asynchronen Ausführung von Berechnungen zeigen, wie uns objektfunktionale Programmierung dabei helfen kann, qualitativ hochwertigen Quellcode zu schreiben. Dabei lege ich besonderen Wert auf Lesbarkeit, Testbarkeit und Ausdrucksstärke. Ich gehe bei der Entwicklung von APIs gern explorativ vor. Es ist wie beim Malen eines Bildes, der Künstler macht einen Strich und lässt das Gesamte auf sich wirken. Seine Intuition bzw. Erfahrung sagt ihm, wo er den nächsten Strich platzieren muss, denn das Bild ist von vornherein da – es wird lediglich sichtbar gemacht.
Sowohl in der funktionalen Programmierung als auch in der objektorientierten Programmierung wird zwischen Daten und Operation auf Daten unterschieden. Eine Funktion hat Eingabe- und Ausgabedaten. Dabei kann eine Funktion selbst auch Eingabe- oder Ausgabedatum sein. Eine solche Funktion nennen wir Funktion höherer Ordnung [1].
<T> void async(Supplier<T> call, Consumer<T> callback); // Anwendungsbeispiel async(() -> computeValue(), value -> { … });
Die beschriebene Funktion async ist eine Funktion höherer Ordnung, da sie zwei Eingabefunktionen besitzt. Die Grenzen generischer Typparameter haben wir hierbei zunächst vernachlässigt (Kasten: „Grenzen generischer Typparameter“). Ein hilfreicher Aspekt der funktionalen Programmierung ist, dass die Signatur einer Methode bereits Hinweise auf deren Bedeutung gibt, denn eine Funktion wandelt gewisse Eingaben in genau eine Ausgabe um. Gibt eine Funktion nichts zurück, ist das ein Indiz dafür, dass die äußere Welt durch Seiteneffekte verändert wird, da die Funktion ansonsten bedeutungslos wäre. Unser Ziel sollte es sein, die äußere Welt nur dann zu verändern, wenn es notwendig ist. Ganz vermeiden können wir es nicht, da ein Programm ohne Seiteneffekte quasi nichts tun würde, außer Rechenkapazität in Anspruch zu nehmen. Es ist hilfreich, Seiteneffekte nicht hart zu kodieren, sondern sie als variables Verhalten in Form von Funktionen in andere Funktionen zu injizieren. In unserem Beispiel tun wir dies mit Hilfe der callback-Funktion.
Grenzen generischer Typparameter
Um die Spanne der Einsatzmöglichkeiten eines API zu maximieren, ist es wichtig, die generischen Typparameter von Funktionsargumenten korrekt zu deklarieren. Im ersten Codebeispiel haben wir der Einfachheit halber die generischen Grenzen vernachlässigt. Als Faustregel darf man aber annehmen, dass Typargumente in Konsumentenposition mit einer unteren Grenze super und in Produzentenposition mit einer oberen Grenze extends versehen werden [2]. In der folgenden Tabelle finden Sie die am häufigsten verwendeten Funktionstypen.
Typ | Entspricht etwa |
Function<? super T, ? extends T> | / |
Consumer<? super T> | Function<? super T, Void> |
Predicate<? super T> | Function<? super T, Boolean> |
Supplier<? extends T> | Function<Void, ? extends T> |
Gelegentlich wird der Entwickler es aufgrund von Unzulänglichkeiten des Java-Typsystems nicht schaffen, den Java-Compiler von der Korrektheit eines Ausdrucks zu überzeugen. In solchen Fällen bedienen wir uns sogenannter Type Witnesses, um der Typinferenz von Java etwas auf die Sprünge zu helfen. Mehr zu dem Thema findet sich unter [3].
In unserem Beispiel findet eine Berechnung call statt, die von async nebenläufig, d. h. in einem anderen Thread, gestartet wird. Das Ergebnis der Berechnung ist die Eingabe von callback. Die Funktion async hat keinen Rückgabewert, sie übergibt die Kontrolle sofort an den Aufrufer zurück, ohne die Programmausführung zu blockieren:
1
2
3
|
<T> void async(Supplier<T> call, Consumer<T> callback) { new Thread(() -> callback.accept( call.get() )).start(); } |
Bei der Anwendung von async werden schnell zwei Dinge auffallen, die sich bereits in der Methodensignatur widerspiegeln. Einerseits erlauben wir derzeit keine Fehler bei der Ausführung von call, denn Supplier deklariert keine Ausnahmefehler. Andererseits gibt es keine Schnittstelle, die es uns gestattet, Fehler zu behandeln.
Kontrollfluss und Fehlerbehandlung
Charakteristisch für den Kontrollfluss eines imperativen Programms ist die sequenzielle Abarbeitung von Befehlen, beispielsweise Wertzuweisungen und das Prüfen von Variableninhalten. Dadurch ist der Quellcode mitunter gesprächig, die wesentliche Logik von Programmteilen wird unnötig auseinandergezogen und die Fehleranfälligkeit ist recht groß. Die Objektorientierung hilft uns dabei, Zustand und Verhalten zu kapseln. Im Kern allerdings bleibt Java dennoch imperativ.
Bei der Verwendung von Lambdaausdrücken ist es in Java nicht möglich, auf lokale Variablen schreibend zuzugreifen, die außerhalb der Funktion deklariert wurden. Der Compiler prüft in solchen Fällen, ob Variablen effektiv final sind. Hier äußert sich ein erster Bruch des imperativen Konzepts: Der Compiler signalisiert uns, dass eine Funktion nicht aktiv die äußere Welt verändern sollte.
Das Werfen einer Exception ist keine elegante Form der Programmierung, wenn der normale Programmablauf unterbrochen und, ähnlich einem Go-to, in eine Subroutine zur Fehlerbehandlung verzweigt wird. Beeinflusst eine geworfene Exception den weiteren Programmablauf, so gilt sie als unerwünschter Seiteneffekt. Eine Funktion sollte die Kontrolle über den Programmablauf behalten und einen definierten Wert nach außen propagieren. Eine Ausnahme bilden hier fatale Fehler, die eine weitere Ausführung unmöglich machen. Dazu zählen z. B. LinkageError oder VirtualMachineError.
Listing 1: Erweiterung der „async“-Methode um Fehlerbehandlung
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
<T> void async(Callable<T> callable, Consumer<T> onSuccess, Consumer<Exception> onFailure) { new Thread(() -> { try { onSuccess.accept(callable.call()); } catch (Exception e) { onFailure.accept(e); } }).start(); } // Anwendungsbeispiel async(() -> computeValue(), value -> { ... }, error -> { ... } ); |
In Listing 1 erweitern wir durch wenige Handgriffe das funktionale API der async-Methode. Die Supplier Funktion wurde durch die Callable-Funktion der Java-Standardbibliothek ersetzt. Sie ermöglicht uns, Fehler zu werfen, die dann von async behandelt werden. Als Schnittstelle für die Fehlerbehandlung haben wir die callback-Funktion durch zwei separate Handler ersetzt. Die onSuccess-Funktion ist für Resultate zuständig, die onFailure-Funktion wird im Fehlerfall aufgerufen.
Die Behandlung von Ergebnissen und Fehlern zu trennen, hat einen Nachteil: Logik und Kontrollfluss werden nicht an einem Ort gehalten. Die objektfunktionale Bibliothek Vavr orientiert sich an Scala und abstrahiert Fehlerbehandlung mithilfe des abstrakten Typs Try. Der grundlegende objektorientierte Anteil an Try ist die Kapselung eines Zustands. Es wird entweder das Ergebnis einer Berechnung oder ein Fehler gespeichert. Deshalb besitzt Try genau zwei Implementierungen: Success und Failure.
1
2
3
4
5
6
7
8
9
|
<T> void async(Callable<T> callable, Consumer<Try<T>> callback) { new Thread(() -> callback.accept( Try.of(callable::call) )).start(); } // Anwendungsbeispiel async(() -> computeValue(), result -> result .onSuccess(value -> { ... }) .onFailure(error -> { ... }) ); |
Try wird mit der Berechnung callable initialisiert und kapselt dabei deren Resultat oder einen eventuell auftretenden Fehler. Nach der Instanziierung wird die Try-Instanz an callback übergeben. Try besitzt zahlreiche Methoden, um das Ergebnis zu transformieren, zu behandeln oder den Zustand der Berechnung abzufragen. Da unsere callback-Funktion keinen Rückgabewert besitzt, bleibt uns lediglich die Möglichkeit, das Ergebnis mithilfe der Methoden onSuccess und onFailure zu behandeln. Wir sehen daher an dieser Stelle im Vergleich zu Listing 1 keinen großen Unterschied seitens des Aufrufers unses async-API. Der Try-Typ hat aber im Gegensatz zu Handlern großes Potenzial. Es wird erst sichtbar, wenn wir zu Zustandstransformationen objektfunktionaler Datentypen kommen.
Kompositionalität objektfunktionaler APIs
Unser bisheriges async-Beispiel mag den Eindruck vermittelt haben, in einfacher Art und Weise asynchrone Berechnungen zu abstrahieren. Versuchen wir jedoch die Ergebnisse asynchroner Berechnungen ebenfalls asynchron weiterzuverarbeiten, so landen wir in der sogenannten Callback-Hölle (Listing 2).
Listing 2: Kaskade asynchroner Callback-Aufrufe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
async(() -> computeValue1(), result1 -> result1 .onSuccess(value1 -> async(() -> computeValue2(value1), result2 -> result2 .onSuccess(value2 -> async(() -> computeValue3(value2), result3 -> result3 .onSuccess(value3 -> { ... }) .onFailure(error3 -> { ... }) ) ) .onFailure(error2 -> { ... }) ) ) .onFailure(error1 -> { ... }) ); |
Ein Reflex könnte sein, die verschachtelten Aufrufe in Methoden zu extrahieren. Prinzipiell ist das eine gute Idee, denn Funktionen sollten kurz und übersichtlich bleiben. In diesem Fall würde es aber auf Kosten der Lesbarkeit gehen. Stattdessen sollte unser oberstes Ziel stets eine hohe Kohäsion der Programmlogik sein. Indem wir die Ablaufsteuerung an einem Ort halten, erhöhen wir die Verständlichkeit und damit die Wartbarkeit des Quellcodes.
Ein funktionales API zeichnet sich durch die Güte seiner Kompositionalität aus, d. h. wir verbinden Funktionsbausteine, um komplexere Funktionen zu erhalten. Die async-Methode hat einen grundlegenden Designaspekt, der zumindest unsere Aufmerksamkeit erregen sollte, sie besitzt keinen Rückgabewert. Hinzu kommt, dass ein Consumer das Ergebnis verarbeitet, indem die äußere Welt verändert wird. Beides geht auf Kosten der Kompositionalität.
Es ist essenziell, dass Funktionen im Idealfall referenziell transparent sein sollten. Das heißt, dieselben Eingaben führen immer zum gleichen Ergebnis, unabhängig vom Zeitpunkt der Ausführung [4]. Solch eine Funktion nennt man pur [5]. Ein Vorteil purer Funktionen ist, dass sie sich hervorragend testen lassen.
Listing 3: Objektfunktionale Variante der „async“-Funktion
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
interface Future<T> { static <T> Future<T> of(Callable<T> call) { throw new NotImplementedError("TODO"); } <U> Future<U> map(Function<T, U> f); Future<T> onComplete(Consumer<Try<T>> callback); } // Anwendungsbeispiel Future.of(() -> computeValue1()) .map(value1 -> computeValue2(value1)) .map(value2 -> computeValue3(value2)) .onComplete(result -> result .onSuccess(value -> { ... }) .onFailure(error -> { ... }) ); |
In Listing 3 haben wir einen Future-Typ hinzugefügt, mit dessen Hilfe wir die Kaskade asynchroner Verarbeitungen aus Listing 2 sequenzialisieren können. Die Methode async wurde zu Future.of umbenannt. Bemerkenswert ist, dass wir die Transformation von Ergebnissen von callback entkoppeln konnten. Möglich macht das die map-Methode, die wieder eine Future-Instanz zurückgibt. Insbesondere kann map aufgerufen werden, bevor das asynchron berechnete Resultat vorliegt.
Im obigen Beispiel nehmen wir eine Sequenz von Transformationen mittels map vor. Ein grundlegender Anwendungsfall bei der Komposition von Future-Aufrufen fehlt jedoch: verschachtelte Transformationen, die auf vorherige Berechnungsergebnisse zugreifen. Die map-Funktion allein reicht hier nicht aus, da ein Wert auf einen anderen Wert projiziert wird, wir jedoch eine Future-Instanz als Ergebnis benötigen, um innerhalb von map eine weitere Transformation durchführen zu können. Solch eine Funktionalität ist unter dem Namen flatMap bekannt.
Listing 4: Ergänzung von Future um „flatMap“
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
interface Future<T> { // ... <U> Future<U> map(Function<T, U> f); <U> Future<U> flatMap(Function<T, Future<U>> f); } // Anwendungsbeispiel Future.of(() -> computeValue1()) .flatMap(value1 -> Future.of(() -> computeValue2()) .map(value2 -> computeValue3(value1, value2)) ) .onComplete(result -> result .onSuccess(value -> { ... }) .onFailure(error -> { ... }) ); |
In Listing 4 ist erkennbar, wie durch die Kombination von map und flatMap eine verschachtelte Berechnung durchgeführt wird. So sind wir beim Aufruf von computeValue3 in der Lage, auf die vorherigen Ergebnisse value1 und value2 zurückzugreifen. Der Nachteil von flatMap ist, dass der Quelltext mit zunehmender Verschachtelungstiefe unlesbar wird. Andere Sprachen besitzen syntaktischen Zucker, um verschachtelte Transformationen zu sequenzialisieren. In Scala ist dies beispielsweise die sogenannte For-Comprehension [6].
1
2
3
4
5
6
7
8
9
10
11
|
// For-Comprehension in Scala val future = for { value1 <- Future { computeValue1() } value2 <- Future { computeValue2() } } yield computeValue3(value1, value2) // Pattern Matching in Scala future.onComplete { case Success(value) => ... // verwende value case Failure(error) => ... // verwende error } |
Ich freue mich darüber, dass Java sich langsam Scala syntaktisch annähert. Mit Java 10 erhalten wir var, das ähnlich zu val den Typ eines Ausdrucks inferiert. Außerdem arbeiten die Java-Spracharchitekten bereits mit JEP 305 an Pattern Matching für Java [7].
Free: Mehr als 40 Seiten Java-Wissen
Lesen Sie 12 Artikel zu Java Enterprise, Software-Architektur und Docker und lernen Sie von W-JAX-Speakern wie Uwe Friedrichsen, Manfred Steyer und Roland Huß.
Struktur objektfunktionaler Datentypen
Objektorientierung und funktionale Programmierung sind unabhängige Konzepte. Verbinden wir sie, so erhalten wir ein Konzept höherer Dimension. Im vorherigen Abschnitt haben wir gesehen, wie wir uns die objektorientierte Kapselung und Typisierung zunutze machen, um Zustände zu modellieren. Mithilfe von Funktionen höherer Ordnung werden Zustände transformiert. Genau genommen sind Funktionen höherer Ordnung lediglich syntaktischer Zucker für anonyme Klassen. Der funktionale Aspekt unseres Future-Beispiels umfasst aber weit mehr als nur Lambdaausdrücke. Nicht explizit erwähnt haben wir bisher, dass in der objektfunktionalen Programmierung Zustände bzw. die zugrunde liegenden Datenstrukturen unveränderlich sind. Eine Zustandsänderung resultiert in einer neuen Instanz, was sie inhärent threadsicher macht. Dies ist beispielsweise Voraussetzung für die funktionalen bzw. persistenten Datenstrukturen von Vavr.
Grundlegende funktionale Datenstrukturen sind z. B. algebraische Datentypen [8]. Hier unterscheidet man zwischen Produkt- und Summentypen. Tupel zählen zu den Produkttypen, die aus Feldern mit Werten bestehen. Datentypen mit zwei Ausprägungen, wie z. B. Try (Success/Failure), zählen zu den Summentypen. Die Eigenschaften algebraischer Datentypen machen sich Programmiersprachen zunutze, um Pattern Matching zu implementieren (Listing 4). Die Gleichheit funktionaler Objekte wird stets über deren Inhalt definiert, nicht über die Speicherreferenz. Auf die Gleichheit ist verlass, da funktionale Objekte unveränderlich sind. In der Regel eignen sie sich daher z. B. gut als Schlüssel einer Map.
Eigenschaften objektfunktionaler Transformationen
Unter objektfunktionaler Transformation verstehen wir die Transformation eines Objekts in eine andere Struktur durch den Aufruf einer höherwertigen Methode. Dabei gibt es ausgezeichnete Methoden, für die allgemeine Gesetzmäßigkeiten gelten, unabhängig von der Klasse, die sie deklariert. In der mathematischen Kategorientheorie [9] untersucht man die Eigenschaften von Abbildungen zwischen Strukturen. Wir haben bereits zwei solcher Methoden von Future kennengelernt, map und flatMap, jedoch ohne sie zu implementieren. Wie könnte eine sinnvolle Implementierung aussehen?
1
2
3
4
5
|
// Identität future.map(value -> value) = future // Komposition future.map(f).map(g) = future.map(value -> g.apply(f.apply(value))) |
In der angewandten Kategorientheorie nennen wir die Klasse Future „Funktor“, wenn sie eine Operation map mit den aufgeführten Eigenschaften besitzt [10]. Dabei sind future eine beliebige Future-Instanz und f, g vom Typ Function mit passenden Eingabe- und Ausgabeparametern. Wir verwenden den Operator @ als prägnante Schreibweise für die Äquivalenz von Objekten, ähnlich equals.
1
2
3
4
5
6
7
8
9
|
// Linksidentität cons.apply(value).flatMap(f) = f.apply(value) // Rechtsidentität future.flatMap(cons) = future // Assoziativität future.flatMap(f).flatMap(g) = future.flatMap(value -> f.apply(value).flatMap(g)) |
Wir nennen die Klasse Future „Monade“, wenn sie ein Funktor ist und zusätzlich eine Operation flatMap mit den aufgeführten Eigenschaften besitzt [11]. Dabei ist cons eine Future-Factory, z. B. v ‑> Future.of(() ‑> v) und f, g vom Typ Function mit passenden Eingabe- und Ausgabeparametern. Noch allgemeiner können wir die Klasse Future in obigen Definitionen auch durch einen beliebigen abstrakten Datentyp ersetzen. Natürlich können map und flatMap auch anders heißen.
Das Anwendungsgebiet von Monaden ist das Einbetten von Werten in einen höheren Zustand. Vavr beinhaltet hier neben weiteren Typen, z. B. Try für Berechnungen in der Gegenwart von Fehlern, Future für nebenläufige Berechnungen und Option für die Modellierung nicht vorhandener Werte. Aber auch Collections wie Set und Map verhalten sich monadisch.
Um auf die Eingangsfrage dieses Abschnitts einzugehen, die Implementierungen von map und flatMap müssen nicht eindeutig existieren. Es sollten lediglich die oben definierten Gesetzmäßigkeiten erfüllt sein. Vavr besitzt eine Implementierung des Typs Future. Der Quellcode ist unter GitHub einsehbar [12].
Beispiele für Monaden der Java-Standardbibliothek
Java hat der Standardbibliothek mit Version 8 zwei monadische Typen hinzugefügt, Optional und CompletableFuture. Die Java-Spracharchitekten haben sich gegen durchgängig gleiche Namen für monadische Operationen entschieden. CompletableFuture besitzt thenApply anstelle von map und thenCompose anstelle von flatMap. Optional bietet hingegen map und flatMap. Aufgrund fehlender Typkonstruktoren [13] ist es in Java nicht möglich, eine abstrakte flatMap-Methode zu deklarieren, die von unterschiedlichen Klassen gleichermaßen implementiert werden kann. Es gibt bereits Bestrebungen, diese Einschränkung mit herkömmlichen Java-Sprachmitteln zu umgehen [14], ich kann die Verwendung solcher Workarounds aber nicht empfehlen, da die resultierenden Typsignaturen nur schwer verständlich sind. Java ist dafür schlichtweg in seiner jetzigen Form nicht ausgelegt.
Scala abstrahiert monadische Typen mit der Schnittstelle FilterMonadic [15]. Klassen, die FilterMonadic implementieren, können in For-Comprehensions verwendet werden. Ich persönlich wünsche mir eine Erweiterung des Java-Typsystems um Typkonstruktoren. Dies ist der letzte fehlende Baustein, um vollwertige objektfunktionale APIs in Java schreiben zu können. Zudem wären Typkonstruktoren ein Türöffner für eine native Spracherweiterung um For-Comprehensions.
Fazit
Es ist faszinierend zu sehen, wie wenige Bausteine notwendig sind, um den grundlegenden Charakter der objektfunktionalen Programmierung zu skizzieren. Anhand eines Beispiels aus der nebenläufigen Programmierung konnten wir gut nachvollziehen, wie das objektfunktionale Paradigma das Beste aus beiden Welten verbindet. Die Bibliothek Vavr implementiert all diese Konzepte und macht sie für den alltäglichen Gebrauch in Java zugänglich.
Links & Literatur
[1] Funktionen höherer Ordnung: https://de.wikipedia.org/wiki/Funktion_höherer_Ordnung
[2] Typargumente in Konsumentenposition: https://docs.oracle.com/javase/tutorial/java/generics/wildcards.html
[3] Type Witnesses: https://docs.oracle.com/javase/tutorial/java/generics/genTypeInference.html
[4] Referenzielle Transparenz: https://de.wikipedia.org/wiki/Referenzielle_Transparenz
[5] Pure Funktion: https://en.wikipedia.org/wiki/Pure_function
[6] For-Comprehension: https://docs.scala-lang.org/tutorials/FAQ/yield.html
[7] JEP 305: http://openjdk.java.net/jeps/305
[8] Algebraische Datentypen: https://en.wikipedia.org/wiki/Algebraic_data_type
[9] Kategorientheorie: https://de.wikipedia.org/wiki/Kategorientheorie
[10] Funktor: https://de.wikipedia.org/wiki/Funktor_(Mathematik)
[11] Monade: https://de.wikipedia.org/wiki/Monade_(Informatik)
[12] Vavr: https://github.com/vavr-io/vavr
[13] Kind-Type-Theorie: https://en.wikipedia.org/wiki/Kind_(type_theory)
[14] KindedJ: https://github.com/KindedJ/KindedJ
[15] Schnittstelle „FilterMonadic“: http://www.scala-lang.org/api/2.12.3/scala/collection/generic/FilterMonadic.html
Erfahren Sie mehr über Java
● What’s new for Java in clouds?
● Das machen wir nebenbei – Concurrency mit CompletableFuture